Skip to content

feat(cli): --emit-types — write the proven types back out as TypeScript (#7685, EXPERIMENTAL) - #7688

Closed
proggeramlug wants to merge 3 commits into
mainfrom
feat/7685-emit-types
Closed

feat(cli): --emit-types — write the proven types back out as TypeScript (#7685, EXPERIMENTAL)#7688
proggeramlug wants to merge 3 commits into
mainfrom
feat/7685-emit-types

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Prototype for #7685. Experimental, behind a flag, marked experimental in the docs, not wired into perry check, gates nothing.

What it is

--opt-report (#6952) surfaces the negative half of representation selection. The positive half — the types Perry had to prove to pick an unboxed representation — was computed and discarded. --emit-types <PATH> writes it out (.json → records, anything else → TypeScript). It is a second consumer of the existing opt_report::Entry stream; no new analysis.

The result: a representation is not a type

Perry has five representation analyses. After auditing each one against "never emit a wrong type", exactly one licenses a TypeScript type.

Analysis Emitted Why
Ptr<Shape> yes a real value proof — exact dynamic class by provenance + containment
canonical I32/U32 no a storage proof. The JS value can be undefined (non-dominating writes to a var seed; int_valued_ta members merge straight into integer_locals) or bigint (the bitwise arm ignores operand types; not_bigint_locals is computed but is not a term in the admission conjunction)
canonical Str no annotation-derived (refined_ty is the declared type verbatim when not Any; nothing checks the initializer) and designed to tolerate the annotation being false — "a type-annotation lie degrades to today's behavior" (expr/slot_rep.rs)
IntValuedTa no self-refuting. Its own module doc: an OOB read "yields undefined …, NOT an integer", and the rep is sound only because rule (2) forbids every context where that is observable. An annotation is such an observation
Ptr<NumArray> no HolesOk is the primary provenance and its slots read back undefined. test-files/test_gap_repsel_p4a3_ptr_numarray.ts already pins console.log(c[0], c[1], c[3])undefined 2 undefined on a promoted local. True type is (number | undefined)[]
spec-ABI no a majority vote, not a proof — see below

The pattern is consistent: these representations were chosen to be observationally equivalent to the boxed form, which is strictly weaker than "the value has this type", and in three cases the equivalence holds because the value is never observed in a distinguishing context.

Two things I got wrong and corrected by measuring

  1. "Spec-ABI just echoes the annotation" — false. codegen/spec_abi.rs::select_dominant_tuple counts argument-type tuples at the call sites and keeps the most frequent, demoting the rest behind a guarded entry. It fires on wholly unannotated JavaScript (A/B'd: identical body, annotations removed, still rep=i32,i32). The real reason to drop it is stronger: a function called 4× with numbers and 1× with a string still reports i32,i32, so a: number would be wrong for a caller that exists.

  2. The synthetic-class filter was a prefix check and leaked. __anon_class_<id> (new (class{})()), Box$num (generic monomorphization) and Name$2 (scope-collision rename) are all real class names reachable at a Ptr<Shape> provenance site; each would have emitted TypeScript naming a type that does not exist. The filter now also rejects any name containing $ (already this repo's reserved generated-suffix namespace). The original list also contained __EmptySite_, which matches nothing in the tree.

Measured coverage

scripts/emit_types_accuracy.py, two modes. Round-trip erases local annotations first — that is load-bearing, not hygiene: refined_ty is the declared type when it is not Any, so measured un-erased, every "recovered" type is the annotation handed back and the score is 100% and means nothing.

corpus files recovered
benchmarks/{repsel_census,suite,app-patterns} (erased .ts) 25 6 bindings (22 of 25 files → zero)
test-files/*.ts (erased) — wide mapping, before the audit 301 2 bindings; 300 files → zero
real dependency JS (lodash, semver, debug, chalk, …) 150 0 bindings / 400 local declarations = 0.00%; 0 structural shapes

The 17 bindings the wide mapping found on dependency JS were all from the four withdrawn arms. Benchmarks still recover 6, which is the positive control that emission is alive rather than broken.

The differentiating feature — a structural interface recovered from untyped JS — fires zero times on real dependency JS. That is consistent with the repsel census, which records Ptr<Shape> promoting ~7 values across an 18-workload corpus and notes "the honest floor for Ptr<Shape> on real code is zero today".

Is it worth pursuing?

On this evidence, no — not as a type emitter, and the PR says so in its own docs rather than burying it. The blocker is not engineering effort; it is that Perry's representations are storage decisions justified by unobservability, and an annotation is an observation. The one sound arm depends on Ptr<Shape>, whose promotion rate on real code is ~zero for reasons tracked separately (#7152/#7170).

Two things would change the answer, both larger than this prototype: recording provenance on canonical-i32 entries (the loop_bounded_i32 and unsigned_i32 admitting sets look genuinely type-sound, but the Entry stream carries only rep: "I32", so they cannot be separated out downstream), and whatever moves Ptr<Shape> off zero.

#7234 does not block this

Not merely because --profile perry-dev inherits release and turns debug_asserts off. The panicking assertion is in opt_report/render.rs::rule_buckets, which folds tiers over Denied entries for the opt-report JSON renderer; --emit-types renders through emit_types.rs and never calls it. Empirically 150/150 dependency-JS files compiled clean.

Also worth flagging

recover's disagreement guard cannot fire on a real compile, and the code now says so. opt_report::take_entries de-duplicates before any consumer sees the stream and Entry::dedup_key omits local_id, rep, shape_class — so two Selected rows for one name with different classes collapse upstream. Closing that means widening dedup_key, which is --opt-report's contract and not this prototype's to change.

Producer-side change

Entry gains shape_class and shape_fields, report-only and allocation-free when the report is off, both skip_serializing_if = "Option::is_none" so an entry that has neither serializes byte-identically to the pre-#7685 schema. They carry the class name and field set as data: detail already rendered them as prose, and recovering a type by parsing an English sentence is a wrong-type bug waiting for someone to reword the sentence.

Verification

  • cargo test -p perry-codegen --lib --no-fail-fast — 797 pass (20 in emit_types)
  • cargo check --all-targets — clean
  • all 24 lint-job commands + cargo fmt --all -- --check — pass (ptr_shape.rs is at 1998/2000 lines; the shape-field helper lives in emit_types.rs partly for that reason)
  • adversarial probe (reassigned string local, OOB typed-array read, conditionally-undefined local, mutated shape) emits nothing, with a positive control in the same file so the probe can fail
  • the accuracy harness exits non-zero if it measures zero files, and treats "compiled fine but wrote no report" as fatal rather than as a zero

Tests are omission-first: every omission is paired with a positive control proving the same input would have emitted if the rule were absent.

Summary by CodeRabbit

  • New Features
    • Added experimental --emit-types <PATH> support for generating TypeScript definitions or JSON output.
    • Emits only proven type information and structural shapes, with unsupported or uncertain results omitted.
    • Added deterministic output, empty-result messaging, and improved shape metadata handling.
  • Documentation
    • Documented the new compiler flag, output formats, limitations, caching behavior, and coverage tools.
  • Tests
    • Added comprehensive coverage for type recovery, filtering, structural interfaces, deduplication, and JSON/TypeScript consistency.

Ralph Küpper added 2 commits August 9, 2026 08:55
…pt (#7685)

EXPERIMENTAL prototype. A second consumer of the `--opt-report` Entry
stream: it keeps the wins and renders them as TypeScript, rather than
rendering the denials.

No new analysis. Coverage is exactly the proof rate --opt-report measures.
#7685)

An audit of each representation against "never emit a wrong type" withdrew
four of the five mapping arms. Only the Ptr<Shape> object proof licenses a
TypeScript type; the numeric/string slot reps are storage decisions that
survive a false annotation, IntValued is sound only while unobserved, and
Ptr<NumArray> admits holes that read back undefined.

Also hardens the synthetic-class filter ($-mangled monomorphization names
and __anon_class_ leaked through a prefix-only check) and corrects the
spec-ABI rationale: it is a majority vote over call sites, not an echo of
the source annotation.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds experimental --emit-types support. Perry records proven pointer-shape metadata, recovers supported local types, renders TypeScript or JSON sidecars, bypasses relevant caches, documents the feature, and adds accuracy and coverage measurement tooling.

Changes

Proven type emission

Layer / File(s) Summary
Shape provenance recording
crates/perry-codegen/src/collectors/ptr_shape.rs, crates/perry-codegen/src/opt_report/*
Optimization report entries now store optional selected pointer-shape classes and fields. Existing selectors remain compatible without shape metadata.
Type recovery and output rendering
crates/perry-codegen/src/emit_types.rs, crates/perry-codegen/src/emit_types/tests.rs, crates/perry-codegen/src/lib.rs
The emitter filters unsupported evidence, reconstructs named and structural types, removes conflicts, and renders deterministic TypeScript or JSON output.
Compile flag and pipeline integration
crates/perry/src/commands/compile/*, crates/perry/src/commands/dev.rs, crates/perry/src/commands/run/mod.rs, docs/src/cli/flags.md, changelog.d/7688-emit-types-prototype.md
The compiler accepts --emit-types <PATH>, disables cache reuse for the reporting run, drains optimization entries, and writes JSON for .json paths or TypeScript otherwise.
Accuracy and coverage measurement
scripts/emit_types_accuracy.py
The script measures roundtrip recovery for TypeScript and coverage for JavaScript, with input discovery, normalization, mismatch reporting, and status handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant CompileArgs
  participant CompilePipeline
  participant OptReport
  participant EmitTypes
  participant OutputFile
  CompileArgs->>CompilePipeline: provide emit_types path
  CompilePipeline->>OptReport: enable recording and drain entries
  OptReport-->>CompilePipeline: return optimization entries
  CompilePipeline->>EmitTypes: render TypeScript or JSON
  EmitTypes->>OutputFile: write emitted types
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the experimental --emit-types CLI feature and its primary TypeScript output.
Description check ✅ Passed The description covers the purpose, implementation, related issues, verification, limitations, and experimental status, despite using custom headings instead of the template.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/7685-emit-types

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
crates/perry-codegen/src/emit_types/tests.rs (1)

206-219: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a direct test for declared_shape_fields.

shape_entry constructs ShapeField values by hand, so every structural test starts after the mapping step. declared_shape_fields is the function that turns a perry_hir::Class chain into that field list, and it carries the two rules the module relies on: a computed key or a private field yields ts_type: None, and the whole chain contributes. Neither rule is exercised. A regression there produces a wrong interface while this file stays green.

Add one test that builds a small class chain and calls declared_shape_fields directly. Cover a private field, a computed key, and a base-class field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/emit_types/tests.rs` around lines 206 - 219, Add a
focused test for declared_shape_fields that constructs a small perry_hir::Class
inheritance chain and invokes the function directly. Assert that private and
computed-key fields produce ShapeField entries with ts_type: None, while a field
from the base class is included in the returned list. Keep existing
shape_entry-based structural tests unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/emit_types.rs`:
- Around line 179-195: The declared_shape_fields function must de-duplicate
field names while traversing the class chain, preserving the first occurrence so
the most-derived declaration wins. Track already-emitted names and skip later
inherited or shadowed fields before pushing ShapeField entries, while retaining
the existing type-generation behavior.

In `@crates/perry-codegen/src/opt_report/mod.rs`:
- Around line 496-517: Update the documentation for the shape_fields field in
the report struct to state that it may be populated for any Ptr<Shape>
selection, including source-level classes such as Point, not only
compiler-synthesized object-literal shapes. Keep the note that emission
currently uses the field only for synthetic classes, and leave
note_ptr_shape_local and the producer behavior unchanged.

In `@crates/perry/src/commands/compile/run_pipeline.rs`:
- Around line 4749-4776: Ensure --emit-types is handled before early returns for
the web, wasm, and widget target handlers, or reject it during preflight for
targets that cannot emit types. Update the target dispatch/preflight logic
around the relevant CompileResult-returning handlers while preserving the
existing native codegen writer behavior.

In `@scripts/emit_types_accuracy.py`:
- Around line 69-107: Update erase_local_annotations and the roundtrip scoring
flow so declarations are not treated as unique solely by binding name. Exclude
every repeated annotated name from scoring, preventing reused names or
any/unknown annotations from affecting unrelated bindings until scope-aware
identities are available. In the coverage calculation, count every DECL_ANY
match rather than de-duplicating names, and remove declaration-count
de-duplication so the reported syntactic count is accurate.

---

Nitpick comments:
In `@crates/perry-codegen/src/emit_types/tests.rs`:
- Around line 206-219: Add a focused test for declared_shape_fields that
constructs a small perry_hir::Class inheritance chain and invokes the function
directly. Assert that private and computed-key fields produce ShapeField entries
with ts_type: None, while a field from the base class is included in the
returned list. Keep existing shape_entry-based structural tests unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f7598d5-e915-4bc7-b6cf-4aafda4e72a6

📥 Commits

Reviewing files that changed from the base of the PR and between e117e86 and 4b347f5.

📒 Files selected for processing (14)
  • changelog.d/7688-emit-types-prototype.md
  • crates/perry-codegen/src/collectors/ptr_shape.rs
  • crates/perry-codegen/src/emit_types.rs
  • crates/perry-codegen/src/emit_types/tests.rs
  • crates/perry-codegen/src/lib.rs
  • crates/perry-codegen/src/opt_report/mod.rs
  • crates/perry-codegen/src/opt_report/render.rs
  • crates/perry/src/commands/compile/build_cache.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/src/commands/compile/types.rs
  • crates/perry/src/commands/dev.rs
  • crates/perry/src/commands/run/mod.rs
  • docs/src/cli/flags.md
  • scripts/emit_types_accuracy.py

Comment on lines +179 to +195
fn declared_shape_fields(chain: &[&perry_hir::Class]) -> Vec<ShapeField> {
let mut out = Vec::new();
for class in chain {
for field in &class.fields {
let ts_type = if field.key_expr.is_some() || field.is_private {
None
} else {
ts_type_for_hir_type(&field.ty)
};
out.push(ShapeField {
name: field.name.clone(),
ts_type,
});
}
}
out
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Establish the ordering `chain_classes` produces (base-first or derived-first).
ast-grep run --pattern 'fn chain_classes($$$) { $$$ }' --lang rust crates/perry-codegen/src/collectors/
rg -n -C10 'fn chain_classes' crates/perry-codegen/src/

Repository: PerryTS/perry

Length of output: 1919


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== emit_types relevant sections =="
sed -n '150,245p' crates/perry-codegen/src/emit_types.rs

echo
echo "== chain_classes implementation =="
sed -n '668,720p' crates/perry-codegen/src/collectors/ptr_shape.rs

echo
echo "== declared_shape_fields call sites =="
rg -n "declared_shape_fields|selected_shape|shape_fields|chain_classes" crates/perry-codegen/src -C 3

echo
echo "== shape field definitions and methods =="
rg -n "struct Shape|impl Shape|struct ShapeField|fn render|fn is_emittable" crates/perry-codegen/src/emit_types.rs -C 5

echo
echo "== code generation path for anonymous classes =="
rg -n "__anon_class|__AnonShape|anon_class|new \\(class extends|extends Base" crates/perry-codegen/src crates/perry/tests packages dist -C 3 || true

echo
echo "== tests around shape/interface generation =="
fd -e rs -e ts -e d.ts . crates/perry-codegen/src crates/perry/tests packages dist | sed -n '1,120p'

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== anon class lowering =="
fd -i 'non_ident.rs' crates/perry-hir/src/lower | xargs sed -n '1,220p'

echo
echo "== synthetic class constructors and extends handling =="
rg -n "__anon_class_|__AnonShape_|anon_shape_class_for_element_type|anon_class|extends_name|fields:" crates/perry-hir/src/lower crates/perry-codegen/src -g '*.rs' -C 4 | sed -n '1,220p'

echo
echo "== relevant structs/tests =="
fd -i 'anon.*shape|shape.*loop|new.*anon|class.*anon' crates/perry/hir crates/perry-codegen -g '*.rs' | sed -n '1,80p'

Repository: PerryTS/perry

Length of output: 30255


De-duplicate shadowed field names across the class chain.

chain_classes builds the chain with self first, so declared_shape_fields adds derived fields before inherited fields. If a derived class redeclares an inherited field, Shape::render emits both entries in one interface and produces a duplicate TypeScript member. Keep only one declaration per field name, with the first/most-derived entry winning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/emit_types.rs` around lines 179 - 195, The
declared_shape_fields function must de-duplicate field names while traversing
the class chain, preserving the first occurrence so the most-derived declaration
wins. Track already-emitted names and skip later inherited or shadowed fields
before pushing ShapeField entries, while retaining the existing type-generation
behavior.

Comment on lines +496 to +517
/// For a `Ptr<Shape>` selection: the provenance class name, verbatim.
///
/// `detail` already renders it into prose (`class Point (0 numeric
/// field(s) proven)`), but `--emit-types` (#7685) turns this into a
/// TypeScript type, and recovering a type by parsing an English sentence
/// is a wrong-type bug waiting for the day somebody rewords the sentence.
/// A consumer that must not guess gets a field, not a substring.
#[serde(skip_serializing_if = "Option::is_none")]
pub shape_class: Option<String>,
/// For a `Ptr<Shape>` selection whose class is a compiler-synthesized
/// object-literal shape: the declared field set of the class chain.
///
/// This is the field set `--emit-types` renders as a structural interface —
/// the one output a JavaScript-only inferencer has no representation-
/// selection pressure to force. Populated only when [`enabled`], like
/// `PtrShapeLocal::report_name`, so an ordinary build allocates nothing.
///
/// Both fields are `skip_serializing_if` so an entry that has neither
/// serializes byte-identically to the pre-#7685 schema; only a `Ptr<Shape>`
/// selection gains keys.
#[serde(skip_serializing_if = "Option::is_none")]
pub shape_fields: Option<Vec<crate::emit_types::ShapeField>>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Show the derive attributes on `Entry` and on `ShapeField`.
rg -n -B6 'pub struct Entry' crates/perry-codegen/src/opt_report/mod.rs
rg -n -B6 'pub struct ShapeField' crates/perry-codegen/src/emit_types.rs
# Any deserialization of the report entries anywhere?
rg -n -C3 'Deserialize' crates/perry-codegen/src/opt_report/

Repository: PerryTS/perry

Length of output: 735


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## opt_report outline"
ast-grep outline crates/perry-codegen/src/opt_report/mod.rs --view compact || true

echo "## relevant opt_report struct sections"
sed -n '400,535p' crates/perry-codegen/src/opt_report/mod.rs | cat -n

echo "## selected_shape definition and nearby usage"
sed -n '1,230p' crates/perry-codegen/src/emit_types.rs | cat -n
sed -n '230,290p' crates/perry-codegen/src/emit_types.rs | cat -n

echo "## ptr_shape collector relevant lines"
sed -n '220,260p' crates/perry-codegen/src/collectors/ptr_shape.rs | cat -n

echo "## deserialize/import checks"
rg -n 'Deserialize|serde_json::from|Entry|opt_report' crates/perry-codegen/src -S

echo "## rust parser serde derives structural check"
python3 - <<'PY'
from pathlib import Path
import re

files = [
    Path("crates/perry-codegen/src/opt_report/mod.rs"),
    Path("crates/perry-codegen/src/emit_types.rs"),
]
for path in files:
    text = path.read_text()
    print(f"\n## {path}")
    for struct in ("struct Entry", "struct ShapeField"):
        m = re.search(r"#\[derive\((.*?)\)\]\s*pub\s+" + struct + r"\s*(?:|<[^>]*>)\s*\{", text, re.S)
        print(f"{struct}: derives={m.group(1)}.serialize={'serde::Serialize' in m.group(1)}.deserialize={'serde::Deserialize' in m.group(1)}" if m else "not found")
PY

Repository: PerryTS/perry

Length of output: 50369


Fix shape_fields documentation to match the producer.

note_ptr_shape_local calls emit_types::selected_shape for every Ptr<Shape> selection, not only compiler-synthesized classes. Keep the producer’s behavior and update the doc so consumers know a source-level class such as Point can also carry a field list. Emission uses it only for synthetic classes, but the JSON schema field still documents too narrow a contract.

📝 Proposed doc correction
-    /// For a `Ptr<Shape>` selection whose class is a compiler-synthesized
-    /// object-literal shape: the declared field set of the class chain.
+    /// For a `Ptr<Shape>` selection: the declared field set of the class
+    /// chain. `--emit-types` uses it only when the class is a
+    /// compiler-synthesized object-literal shape.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// For a `Ptr<Shape>` selection: the provenance class name, verbatim.
///
/// `detail` already renders it into prose (`class Point (0 numeric
/// field(s) proven)`), but `--emit-types` (#7685) turns this into a
/// TypeScript type, and recovering a type by parsing an English sentence
/// is a wrong-type bug waiting for the day somebody rewords the sentence.
/// A consumer that must not guess gets a field, not a substring.
#[serde(skip_serializing_if = "Option::is_none")]
pub shape_class: Option<String>,
/// For a `Ptr<Shape>` selection whose class is a compiler-synthesized
/// object-literal shape: the declared field set of the class chain.
///
/// This is the field set `--emit-types` renders as a structural interface —
/// the one output a JavaScript-only inferencer has no representation-
/// selection pressure to force. Populated only when [`enabled`], like
/// `PtrShapeLocal::report_name`, so an ordinary build allocates nothing.
///
/// Both fields are `skip_serializing_if` so an entry that has neither
/// serializes byte-identically to the pre-#7685 schema; only a `Ptr<Shape>`
/// selection gains keys.
#[serde(skip_serializing_if = "Option::is_none")]
pub shape_fields: Option<Vec<crate::emit_types::ShapeField>>,
/// For a `Ptr<Shape>` selection: the provenance class name, verbatim.
///
/// `detail` already renders it into prose (`class Point (0 numeric
/// field(s) proven)`), but `--emit-types` (`#7685`) turns this into a
/// TypeScript type, and recovering a type by parsing an English sentence
/// is a wrong-type bug waiting for the day somebody rewords the sentence.
/// A consumer that must not guess gets a field, not a substring.
#[serde(skip_serializing_if = "Option::is_none")]
pub shape_class: Option<String>,
/// For a `Ptr<Shape>` selection: the declared field set of the class
/// chain. `--emit-types` uses it only when the class is a
/// compiler-synthesized object-literal shape.
///
/// This is the field set `--emit-types` renders as a structural interface —
/// the one output a JavaScript-only inferencer has no representation-
/// selection pressure to force. Populated only when [`enabled`], like
/// `PtrShapeLocal::report_name`, so an ordinary build allocates nothing.
///
/// Both fields are `skip_serializing_if` so an entry that has neither
/// serializes byte-identically to the pre-#7685 schema; only a `Ptr<Shape>`
/// selection gains keys.
#[serde(skip_serializing_if = "Option::is_none")]
pub shape_fields: Option<Vec<crate::emit_types::ShapeField>>,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/opt_report/mod.rs` around lines 496 - 517, Update
the documentation for the shape_fields field in the report struct to state that
it may be populated for any Ptr<Shape> selection, including source-level classes
such as Point, not only compiler-synthesized object-literal shapes. Keep the
note that emission currently uses the field only for synthetic classes, and
leave note_ptr_shape_local and the producer behavior unchanged.

Comment on lines +4749 to +4776
//
// `--emit-types` (#7685) reads the same stream, so the sink is drained ONCE
// here and both consumers read that snapshot. Draining per consumer would
// give the second one an empty vector and silently write an empty types
// file whenever both flags were passed.
if opt_report_format.is_some() || emit_types_path.is_some() {
let entries = perry_codegen::opt_report::take_entries();
let rendered = match fmt {
OptReportFormat::Json => perry_codegen::opt_report::render_json(&entries),
OptReportFormat::Text => perry_codegen::opt_report::render_text(&entries),
};
eprintln!("{rendered}");
if let Some(fmt) = opt_report_format {
let rendered = match fmt {
OptReportFormat::Json => perry_codegen::opt_report::render_json(&entries),
OptReportFormat::Text => perry_codegen::opt_report::render_text(&entries),
};
eprintln!("{rendered}");
}
if let Some(path) = emit_types_path.as_ref() {
let json = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("json"));
let rendered = if json {
perry_codegen::emit_types::render_json(&entries)
} else {
perry_codegen::emit_types::render_ts(&entries)
};
std::fs::write(path, rendered).map_err(|e| {
anyhow::anyhow!("--emit-types: could not write {}: {e}", path.display())
})?;
eprintln!("Wrote types: {}", path.display());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/perry/src/commands/compile --items all --type function
rg -nP --type rust -C 5 \
  'fn\s+compile_for_(wasm|ios_widget|watchos_widget|android_widget|wearos_tile)|emit_types|emit-types' \
  crates/perry/src/commands

cargo check -p perry --profile perry-dev

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== run_pipeline around return targets and sidecar write =="
sed -n '420,550p' crates/perry/src/commands/compile/run_pipeline.rs | cat -n | sed 's/^/line /'

echo "== targets.rs function blocks for relevant compile handlers =="
rg -n --type rust 'pub\s+fn\s+compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile)|emit_types_path|opt_report_format|OptReportFormat|--emit-types' crates/perry/src/commands/compile/targets.rs crates/perry/src/commands/compile/run_pipeline.rs -C 8

echo "== precise call sites in compile handlers =="
python3 - <<'PY'
import re
from pathlib import Path
p=Path('crates/perry/src/commands/compile/targets.rs')
text=p.read_text()
for fn in ['compile_for_web', 'compile_for_wasm', 'compile_for_ios_widget', 'compile_for_watchos_widget', 'compile_for_android_widget', 'compile_for_wearos_tile']:
    m=re.search(r'pub\s+fn\s+'+re.escape(fn)+r'\([^{]*(?:\{(?!\w+::\{|pub\s+fn\s+)\n(?s).*?(?=\n    \})', text)
    if not m:
        m=re.search(r'pub\s+fn\s+'+re.escape(fn)+r'\([^{]*(?:\{(?!\w+::\{|pub\s+fn\s+)[\s\S]*?(?=\n\})', text)
    print(f'\n-- {fn} --')
    if not m:
        print('not found')
        continue
    # simple brace balance
    s=m.start(); e=text.find('}', s); depth=0; ok=False
    # not relying on one char; scan from first "{" after signature
    start=text.find('{',s); depth=1; e=0
    for i,ch in enumerate(text[start:]):
        if ch=='{': depth+=1
        elif ch=='}':
            depth-=1
            if depth==0:
                e=start+i
                break
    body=text[s:e+1]
    lines=body.splitlines()
    for idx,line in enumerate(lines,1):
        if any(kw in line for kw in ['emit_types_path', 'opt_report_format', 'OptReportFormat', '--emit-types']):
            print(f'{idx}: {line}')
    print(body)
PY

echo "== perry_codegen opt/emit symbols =="
rg -n --type rust 'pub\s+struct|struct\s+.*Entries|pub\s+fn\s+take_entries|render_json|render_ts|emit_types' crates/perry/src -C 4

Repository: PerryTS/perry

Length of output: 15907


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate relevant symbols =="
rg -n --type rust 'pub\s+fn\s+(compile_for_ios_widget|compile_for_watchos_widget|compile_for_android_widget|compile_for_wearos_tile|compile_for_web|compile_for_wasm)\(' crates/perry/src/commands/compile/targets.rs
rg -n --type rust 'PubArgs|CompileArgs|struct\s+.*Args|emit_types|opt_report' crates/perry/src/commands/compile -g '*.rs' | head -200

echo "== Read compile_for wasm/web/widget bodies by file locations =="
wc -l crates/perry/src/commands/compile/targets.rs
rg -n --type rust 'compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile)\(' crates/perry/src/commands/compile/targets.rs

python3 - <<'PY'
from pathlib import Path
p=Path('crates/perry/src/commands/compile/targets.rs')
lines=p.read_text().splitlines()
print("file lines:", len(lines))
def find_block(name):
    for i,l in enumerate(lines,1):
        if f"compile_for_{name}(" in l:
            return i
for name in ["wasm","web","ios_widget","watchos_widget","android_widget","wearos_tile"]:
    i=find_block(name)
    print("\n==", name, "starts line", i, "==")
    if not i: continue
    # Print 200 lines if available and enough
    for j in range(i, min(i+220, len(lines)+1)):
        print(f"{j}: {lines[j-1]}")

# Read CLI arg definitions around emit
for defn in ["OptReportFormat", "emit_types", "opt_report"]:
    print("\n== snippets around", defn, "==")
    for j,l in enumerate(lines,1):
        if defn in l:
            lo=max(1,j-20); hi=min(len(lines),j+80)
            for k in range(lo,hi+1):
                print(f"{k}: {lines[k-1]}")

# Read run_pipeline call/signature context
r=Path('crates/perry/src/commands/compile/run_pipeline.rs')
rl=r.read_text().splitlines()
for j,l in enumerate(rl,1):
    if "PubArgs" in l or "fn run_pipeline" in l or "compile_for_wasm" in l or "compile_for_ios_widget" in l:
        print(f"{j}: {l}")
PY

Repository: PerryTS/perry

Length of output: 181


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file sizes and candidate files =="
wc -l crates/perry/src/commands/compile/targets.rs crates/perry/src/commands/compile/run_pipeline.rs crates/perry/src/commands/compile/cli*.rs crates/perry/src/commands/compile/*.rs 2>/dev/null | sort -n | tail -50

echo "== exact symbol definitions/usages =="
rg -n 'compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile)|compile_for_wasm|compile_for_web|emit_types|opt_report|OptReportFormat|OptReportFormat|PubArgs|CompileArgs|struct\s+.*Args' crates/perry/src/commands/compile crates/perry/src -S --glob '*.rs' | head -300

echo "== targets.rs excerpts without regex compilation =="
awk '/compile_for_wasm|compile_for_web|compile_for_ios_widget|compile_for_watchos_widget|compile_for_android_widget|compile_for_wearos_tile|emit_types|opt_report|OptReportFormat/{flag=1} flag{print NR": "$0} flag && /^pub fn |^fn /{if(NR>start+220) flag=0} END{}' crates/perry/src/commands/compile/targets.rs | sed -n '1,260p'

echo "== cli args emit/opt report =="
rg -n 'emit_types|opt_report|OptReportFormat' crates/perry/src -S --glob '*.rs' -C 5 | sed -n '1,220p'

Repository: PerryTS/perry

Length of output: 2617


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target handler signatures and early exit context =="
rg -n --type rust 'pub fn compile_for_(wasm|ios_widget|watchos_widget|android_widget|wearos_tile|web)\(|pub fn compile_for_wasm|pub fn compile_for_web' crates/perry/src/commands/compile/targets.rs -C 3

echo "== target handler call sites =="
rg -n --type rust 'compile_for_(wasm|ios_widget|watchos_widget|android_widget|wearos_tile|web)\(' crates/perry/src -S

echo "== relevant targets.rs ranges =="
for pat in 'compile_for_wasm\(' 'compile_for_ios_widget\(' 'compile_for_watchos_widget\(' 'compile_for_android_widget\(' 'compile_for_wearos_tile\(' 'compile_for_web\(' 'OptReportFormat' 'emit_types'; do
  echo "--- $pat ---"
  rg -n --type rust "$pat" crates/perry/src/commands/compile/targets.rs -C 8 | sed -n '1,220p'
done

echo "== command arg definitions for emit/opt flags =="
rg -n --type rust 'emit_types|opt_report|OptReportFormat|pub struct .*Args|struct .*Args' crates/perry/src --glob '*.rs' -C 6 | sed -n '1,260p'

Repository: PerryTS/perry

Length of output: 206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== list compile command files and perry command module =="
git ls-files crates/perry/src/commands/compile | sed -n '1,120p'
rg -n --type rust 'pub\s+mod\s+compile|mod\s+compile|commands::compile|run_pipeline::run_pipeline|PubArgs' crates/perry/src crates -S --glob '*.rs' | sed -n '1,200p'

echo "== search all perry source for compile_for identifiers =="
rg -n 'compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile|android|ios|watchos|android_widget|wearos_tile|ios_widget|watchos_widget|web|wasm)' crates/perry/src --glob '*.rs' -S | sed -n '1,260p'

echo "== search for type definitions containing emit_types/opt_report in perry src =="
rg -n 'emit_types|opt_report|OptReportFormat' crates/perry/src --glob '*.rs' -S | sed -n '1,260p'

Repository: PerryTS/perry

Length of output: 17821


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for start in 228 545 999 1265 1365 1520; do
  echo "== targets.rs lines $((start-10)) to $((start+140)) =="
  sed -n "$((start-10)),$((start+140))p" crates/perry/src/commands/compile/targets.rs | cat -n | sed -n '1,160p'
done

echo "== compile.rs run around target handlers =="
sed -n '100,220p' crates/Autobench/perry/src/commands/compile.rs 2>/dev/null || sed -n '100,220p' crates/perry/src/commands/compile.rs | cat -n | sed -n '1,130p'

Repository: PerryTS/perry

Length of output: 42175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== run_pipeline dispatch and all compile_for paths =="
sed -n '440,515p' crates/perry/src/commands/compile/run_pipeline.rs | cat -n | sed 's/^/line /'

echo "== targets.rs later return blocks =="
sed -n '1600,1730p' crates/perry/src/commands/compile/targets.rs | cat -n | sed 's/^/line /'

echo "== search for --emit-types support text in docs/comments =="
rg -n 'emit-types|emit_types|types.*sidecar|sidecar.*types|emit.*types' crates/perry/src README.md docs . --glob '*.md' --glob '*.rs' -S | sed -n '1,220p'

echo "== deterministic control-flow verifier for early-return target bodies =="
python3 - <<'PY'
import re
from pathlib import Path
p=Path('crates/perry/src/commands/compile/targets.rs')
run=Path('crates/perry/src/commands/compile/run_pipeline.rs')
lines=p.read_text().splitlines()
handlers=['compile_for_web','compile_for_wasm','compile_for_ios_widget','compile_for_watchos_widget','compile_for_android_widget','compile_for_wearos_tile']
for fn in handlers:
    m=re.search(fr'{re.escape(fn)}\s*\([\s\S]*?\)\s*->\s*Result<CompileResult>', ''.join(lines))
    if not m:
        continue
    start=lines.index(next(l for l in lines if fn+'(' in l))
    # balance braces after signature line opening brace
    body='\n'.join(lines[start:])
    first=body.find('{')
    depth=0; end=-1
    for idx,ch in enumerate(body[first:], first+1):
        if ch == '{':
            depth += 1
        elif ch == '}':
            depth -= 1
            if depth == 0:
                end = idx
                break
    blob=body[first:end+1]
    print(f"{fn}: emit_types_path={'emit_types_path' in blob}; compile_result_return={bool(re.search(r'\n\s*Ok\s*\(\s*CompileResult\s*\{', blob))}; has_return={len(re.findall(r'r\s*\(', blob))} direct return statements (including Err/println/other) = {len(re.findall(r'\breturn\b', blob))}")
runlines=run.read_text().splitlines()
for idx,l in enumerate(runlines,1):
    if re.search(r'\breturn\s+compile_for_.*widget|compile_for_wasm|compile_for_web', l):
        print(f"run_pipeline line {idx}: {l.strip()}")
PY

Repository: PerryTS/perry

Length of output: 50370


Write or reject --emit-types before returning from early targets.

The common writer after the dispatch only handles the native codegen path. The web, wasm, and widget handlers return CompileResult before reaching it, so perry compile --target web --emit-types PATH can succeed without writing PATH. Add --emit-types support in those handlers, or reject the option during preflight when they cannot emit it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry/src/commands/compile/run_pipeline.rs` around lines 4749 - 4776,
Ensure --emit-types is handled before early returns for the web, wasm, and
widget target handlers, or reject it during preflight for targets that cannot
emit types. Update the target dispatch/preflight logic around the relevant
CompileResult-returning handlers while preserving the existing native codegen
writer behavior.

Source: Coding guidelines

Comment on lines +69 to +107
def erase_local_annotations(source: str) -> tuple[str, dict[str, str], set[str]]:
"""Strip `: T` from local declarations.

Returns the erased source, the ground truth it removed (keyed by binding
name), and the set of names whose annotation was `any`/`unknown`.

A name declared twice with different types is dropped from the truth rather
than resolved: this harness scores by name (the report carries no span), so
an ambiguous name cannot be scored honestly either way.
"""
truth: dict[str, str] = {}
ambiguous: set[str] = set()
improved_on_any: set[str] = set()

def repl(m: re.Match[str]) -> str:
name, ty = m.group(1), normalize_type(m.group(2))
if name in truth and truth[name] != ty:
ambiguous.add(name)
# `any` / `unknown` are the ABSENCE of a claim, not a claim. Scoring
# against them would count "we recovered a structural interface for a
# local the author gave up on" as a WRONG answer — which is backwards,
# and is exactly what the first run of this harness did report.
# They are erased like any other annotation (so the compiler cannot
# read them) but excluded from the ground truth, and counted
# separately as an improvement.
if ty not in ("any", "unknown"):
truth[name] = ty
else:
ambiguous.discard(name)
improved_on_any.add(name)
# Rebuild the declaration without its annotation: keep everything up to
# the name, then go straight to the `=`.
head = m.group(0)[: m.start(1) - m.start(0)]
return f"{head}{name} ="

erased = DECL_ANNOTATED.sub(repl, source)
for name in ambiguous:
truth.pop(name, None)
return erased, truth, improved_on_any

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Count declarations by identity, not only by name.

truth, any_names, and recovered bindings use only name. Reused names in separate functions or nested scopes can increase exact multiple times while increasing truth_total once. A reused any name can also suppress scoring for an unrelated typed binding. Line 238 also de-duplicates declarations despite reporting a syntactic declaration count. These errors can inflate the reported accuracy and coverage.

Exclude every repeated annotated name from roundtrip scoring until the harness can key it by scope. Count all DECL_ANY matches for coverage.

Proposed fix
+from collections import Counter
+
 def erase_local_annotations(source: str) -> tuple[str, dict[str, str], set[str]]:
+    annotation_counts = Counter(
+        m.group(1) for m in DECL_ANNOTATED.finditer(source)
+    )
     truth: dict[str, str] = {}
     ambiguous: set[str] = set()
     improved_on_any: set[str] = set()

     def repl(m: re.Match[str]) -> str:
         name, ty = m.group(1), normalize_type(m.group(2))
+        if annotation_counts[name] > 1:
+            ambiguous.add(name)
         if name in truth and truth[name] != ty:
             ambiguous.add(name)
@@
     for name in ambiguous:
         truth.pop(name, None)
+    improved_on_any.difference_update(ambiguous)
     return erased, truth, improved_on_any
@@
-        decls = len(set(DECL_ANY.findall(source)))
+        decls = len(DECL_ANY.findall(source))

Also applies to: 233-258

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/emit_types_accuracy.py` around lines 69 - 107, Update
erase_local_annotations and the roundtrip scoring flow so declarations are not
treated as unique solely by binding name. Exclude every repeated annotated name
from scoring, preventing reused names or any/unknown annotations from affecting
unrelated bindings until scope-aware identities are available. In the coverage
calculation, count every DECL_ANY match rather than de-duplicating names, and
remove declaration-count de-duplication so the reported syntactic count is
accurate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant